Skip to main content

Component Patterns

Component Composition

Compound Components

  • Compound Components are a pattern where a parent component manages shared state or logic, and child components (the "compounds") communicate with that parent implicitly via React’s context or props — rather than passing a lot of props manually.
  • Think of how <Select>, <Select.Option>, and <Select.Label> work in libraries like Radix UI or React Aria — that’s the pattern.

Problem

  • Problems:
    • The user cannot easily customize the internal layout.
    • You must expose more and more props for customization (buttonText, labelColor, layout, etc.).
    • The API becomes rigid and bloated.
//usage
<Toggle
on={isOn}
onToggle={() => setIsOn(!isOn)}
onLabel="Light Mode"
offLabel="Dark Mode"
/>

function Toggle({ on, onToggle, onLabel, offLabel }) {
return (
<div>
<span>{on ? onLabel : offLabel}</span>
<button onClick={onToggle}>Toggle</button>
</div>
);
}

Solution

  • Instead, we make a parent <Toggle> that holds the logic (state), and child components that access that logic automatically using React Context.
  • Step 1: Create the parent component with context
    • <Toggle> that holds the logic (state), and child components that access that logic automatically using React Context.
import React, { createContext, useContext, useState } from "react";

const ToggleContext = createContext(null);

export function Toggle({ children }) {
const [on, setOn] = useState(false);
const toggle = () => setOn((prev) => !prev);

return (
<ToggleContext.Provider value={{ on, toggle }}>
{children}
</ToggleContext.Provider>
);
}

export function useToggleContext() {
const context = useContext(ToggleContext);
if (!context) throw new Error("Toggle compound components must be used inside <Toggle>");
return context;
}
  • Step 2: Create the Compound Components
    • Now the parent manages logic, and children “just work” without passing props around. You can rearrange, style, or extend them freely.
export function ToggleOn({ children }) {
const { on } = useToggleContext();
return on ? children : null;
}

export function ToggleOff({ children }) {
const { on } = useToggleContext();
return !on ? children : null;
}

export function ToggleButton() {
const { on, toggle } = useToggleContext();
return (
<button onClick={toggle}>
{on ? "Switch Off" : "Switch On"}
</button>
);
}
  • Step 3: Compose them better
    • The parent doesn’t expose tons of props.
    • Children automatically access shared state (no prop drilling).
    • Consumers can rearrange the internal structure however they want.
    • New sub-components can be added easily (<Toggle.Icon>, <Toggle.Label>, etc.).
export default function App() {
return (
<Toggle>
<ToggleOn>Light Mode</ToggleOn>
<ToggleOff>Dark Mode</ToggleOff>
<ToggleButton />
</Toggle>
);
}

Prop-Based Composition

  • You pass React elements as props into a parent component.
  • This gives the parent component control over layout or logic, while allowing users to customize certain sections.
  • When to use
    • When the layout is fixed, but you want to inject custom parts (e.g., header, footer, icons).
    • Common in Cards, Modals, Layouts, etc.
type CardProps = {
title: string;
body: string;
footer?: React.ReactNode; // Custom footer passed as prop
};

export function Card({ title, body, footer }: CardProps) {
return (
<View style={{ backgroundColor: "white", padding: 20, borderRadius: 8 }}>
<Text style={{ fontWeight: "bold" }}>{title}</Text>
<Text>{body}</Text>
{footer && <View style={{ marginTop: 10 }}>{footer}</View>}
</View>
);
}

// Usage
<Card
title="Welcome"
body="This is your dashboard."
footer={<Button title="Continue" onPress={() => console.log("Clicked")} />}
/>

Children Prop Composition

  • The parent defines a container or structure and uses props.children to render whatever is passed inside.
  • When to use
    • When the parent component doesn’t need to know the children’s structure.
    • Great for layouts, wrappers, providers, cards, etc.
type CardProps = {
children: React.ReactNode;
};

export function Card({ children }: CardProps) {
return (
<View style={{ backgroundColor: "white", padding: 20, borderRadius: 8 }}>
{children}
</View>
);
}

// Usage
<Card>
<Text style={{ fontWeight: "bold" }}>Welcome</Text>
<Text>This is your dashboard.</Text>
<Button title="Continue" />
</Card>

Render Props

  • A render prop is a function prop that returns JSX.
  • It allows a component to expose internal state to whatever is passed in — giving the caller control over rendering.
  • When to use
    • When you want to share logic (like hover, fetch, or toggle) but give full control over rendering.

Render Props Based: Considered Old Syntax

const DataFetcher = ({ render }) => {
const data = "Fetched data!";
return render(data);
};

const App = () => (
<DataFetcher render={(data) => <p>{data}</p>} />
);

Children-As-A-Function Based: Considered Modern Syntax

const DataFetcher = ({ url, children }) => {
const data = "Fetched data";
return children(data);
};

// Usage
<DataFetcher url="https://api.example.com/users">
{(data) => (
<FlatList
data={data}
renderItem={({ item }) => <Text>{item.name}</Text>}
/>
)}
</DataFetcher>


Encapsulate Component's State

  • Source Encapsulate as much state as possible in your component
  • The idea of “Encapsulate as much state as possible in your component” is a React design principle and pattern that encourages keeping state localized (private) inside components rather than lifting it unnecessarily to parents or external stores.
  • By contrast, encapsulated state:
    • keeps components self-contained
    • prevents unnecessary re-renders up the tree
    • simplifies the mental model (each component handles itself) and increases re-usability.

Example 1: Button

  • You click the button, it transitions to a loading state, then it transitions to either a success or error state.
  • Whereby every bit of state that component could be in, is controlled by the parent and passed in as props.
type SpecialButtonProps = {
onClick: () => void;
state: "loading" | "error" | "success" | "pending";
};

export function SpecialButton(props: SpecialButtonProps) {
return <button onClick={props.onClick} disabled={props.state === "loading"} className={`special-button ${props.state}`}>
{props.state === "loading" && <span>Loading...</span>}
{props.state === "error" && <span >Error!</span>}
{props.state === "success" && <span >Success!</span>}
{props.state === "pending" && <span>Click Me</span>}
</button>
}
  • one that encapsulates the state transitions internally
type SpecialButtonProps = {
onClick: () => Promise<{ success: boolean }>;
};

export function SpecialButton2(props: SpecialButtonProps) {
const [state, setState] = useState<"loading" | "error" | "success" | "pending">("pending");

const handleClick = async () => {
setState("loading");
try {
const result = await props.onClick();
setState(result.success ? "success" : "error");
} catch (error) {
setState("error");
}
};

return <button onClick={handleClick} disabled={state === "loading"} className={`special-button ${state}`}>
{state === "loading" && <span>Loading...</span>}
{state === "error" && <span>Error!</span>}
{state === "success" && <span>Success!</span>}
{state === "pending" && <span>Click Me</span>}
</button>
}
  • usage
const mockAsyncOperation = async () => {
await new Promise((res) => setTimeout(res, 100));
return { success: true };
}

return <SpecialButton2 onClick={mockAsyncOperation} />

Example 2: Autocomplete

  • Before encapsulating state
export function Interactive() {

//States and effects

return <>
<Autocomplete
searchValue={searchValue}
onChangeSearchValue={async (str) => {
setSearchValue(str);
if (searchValue.length < 3) {
setAvailableOptions([]);
return;
}
setIsLoading(true);
try {
const result = await searchFn(searchValue, 1);
}
catch {
setAvailableOptions([]);
}
finally {
setIsLoading(false);
}
}}
onSelectValue={(value) => {
setSelectedValue(value);
setSearchValue(value.name);
setAvailableOptions([]);
}}
renderItem={(v) => { return <div>{v.name}</div> }}
isLoading={isLoading}
availableOptions={availableOptions}
/>
</>
}
  • after encapsulating
    • in the future , if you want to add debouncing, cancelling, pagination logic, all of that is encapsulated, hidden away inside, the parent component - the consumer does not need to think about, it's all taken care of for them.
export function Interactive() {
return (
<div>
<Autocomplete
searchFn={searchFn}
renderItem={(item) => <div>{item.name} - {item.description}</div>}
itemKey="id"
selectedValueDisplayStringFn={(item) => item.name}
/>
</div>
);
}